iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
Vue.js

需求至上的 Vue 魔法之旅系列 第 22

Day 16 : 結界與通行證:登入、權限與路由守衛

  • 分享至 

  • xImage
  •  

前言

在魔法學院裡,並非每個房間都對所有人敞開。有的房間需要通行證(token),有的房間只對長老(admin)開放。今天,我們在 Day15 的「傳送門術(Vue Router)」之上,施加結界(路由守衛)身份驗證(登入)

  • 前端負責「進門前的檢查」與「通行證保管」。
  • 後端負責「最終審核」與「資料護城河」。

今天會學會的魔法:發放簡易 token、前後端共同驗證、保護路由 /summary、保護單筆訂單 /order/:id


一、使用者需求與 User Story(以人為本的魔法劇場)

故事引導

  • 「我只想登入後開始點單。」
  • 「我是祕書/管理者,需要看到總表才能對帳。」
  • 「我想把自己的訂單連結丟給客服,但不想讓其他人看到。」

User Story 表

需求 角色 目的 功能 使用時機
登入取得 token 與角色 所有使用者 進入系統 POST /api/login 進入首頁前
查看統計頁 admin 掌握整體 前端路由守衛 + /summary 登入後
檢視單筆訂單 下單者 / admin 查詢或對帳 後端驗證 token 屬主或 admin 進入 /order/:id

預設規則:roni 是 admin;其他帳號為 user。不存在的帳號會自動建立(密碼固定 123456),登入後回傳 { username, role, token }


時序圖(登入到進入頁面)

這是我們新的登入功能的流程

我們會模擬一個token登入功能

塞到localstorage

https://ithelp.ithome.com.tw/upload/images/20251006/20121052zv3t4RsXbt.png


流程圖(全域守衛與單筆詳情)

https://ithelp.ithome.com.tw/upload/images/20251006/20121052Hx8BWSEmyS.png


二、技術重點(術式清單)

  • 前端

    • Vue Router 全域守衛:進門檢查
    • Pinia authStore:保管 { username, role, token }
    • localStorage 持久化登入狀態
    • Axios 在需要時夾帶 Bearer Token
  • 後端

    • Express + 檔案型資料(user.jsonorder.json
    • base64(username) 當示範 token(簡化教學;實務建議 JWT)
    • GET /api/orders/:id 進行身份授權本人admin 才可觀看

三、程式碼改動(新增登入與授權

後端程式碼改動(新增登入與授權

  • 新增 backend/user.json
  • POST /api/login:密碼必須 123456;不存在就自動建立;回傳 { username, role, token }roni 為 admin)
  • GET /api/orders/:id:需 Authorization: Bearer <token>;僅本人或 admin 可取

user.json 範例(初始化與示意)

day16/backend/user.json

[
  {
    "username": "roni",
    "password": "123456",
    "role": "admin",
    "token": "cm9uaQ=="
  },
  {
    "username": "kevin",
    "password": "123456",
    "role": "user",
    "token": "a2V2aW4="
  }
]

day16/backend/server.js

import express from "express";
import { promises as fs } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import cors from "cors";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const app = express();
const PORT = 3000;
const DATA_FILE = path.join(__dirname, "order.json");
const MENU_FILE = path.join(__dirname, "ordermenu.json");
const USER_FILE = path.join(__dirname, "user.json");

app.use(cors());
app.use(express.json());

async function readUsers() {
  try {
    const txt = await fs.readFile(USER_FILE, "utf8");
    if (!txt) return [];
    return JSON.parse(txt);
  } catch (error) { return []; }
}
async function writeUsers(users) {
  await fs.writeFile(USER_FILE, JSON.stringify(users, null, 2), "utf8");
}
function encodeToken(username){ return Buffer.from(username,'utf8').toString('base64') }
function decodeToken(token){ try{ return Buffer.from(token,'base64').toString('utf8') }catch{ return '' } }

// 登入(密碼需為 123456)
app.post('/api/login', async (req, res) => {
  try {
    const username = (req.body?.username || '').trim();
    const password = (req.body?.password || '').trim();
    if (!username) return res.status(400).json({ error: 'username 必填' });
    if (password !== '123456') return res.status(401).json({ error: '密碼錯誤' });

    let users = await readUsers();
    let user = users.find(u => u.username === username);
    const role = username === 'roni' ? 'admin' : 'user';

    if (!user) {
      user = { username, password: '123456', role, token: encodeToken(username) };
      users.push(user);
      await writeUsers(users);
    } else {
      if (user.password !== '123456') return res.status(401).json({ error: '密碼錯誤' });
      user.token = encodeToken(username);
      user.role = role;
      await writeUsers(users);
    }
    res.json({ username: user.username, role: user.role, token: user.token });
  } catch (e) { res.status(500).json({ error: '登入失敗' }); }
});

// 單筆訂單授權:本人或 admin
app.get("/api/orders/:id", async (req, res) => {
  try {
    const auth = req.headers['authorization'] || '';
    const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
    if (!token) return res.status(401).json({ error: '未提供授權' });
    const users = await readUsers();
    const username = decodeToken(token);
    const me = users.find(u => u.username === username && u.token === token);
    if (!me) return res.status(401).json({ error: '授權無效' });
    // ...讀取 orders 並檢查屬主或 admin(略,見原始碼)
  } catch (e) { res.status(500).json({ error: '無法取得訂單' }); }
});

前端改動(守衛、登入、帶 Token

  • stores/authStore.js:保存 { username, role, token } + localStorage;提供 isAuthenticatedisAdmin
  • pages/LoginPage.vue:呼叫 /api/login,成功後存入 store → 導向 /order
  • router/index.js:新增 /login全域守衛:未登入導向 /login/summary 需 admin
  • services/orderService.jsgetById(id, token)Authorization header
  • pages/OrderDetailPage.vue:從 store 取 token,呼叫 OrderService.getById
  • App.vue:新增「登出」按鈕,auth.clear() + 轉 /login

day16/frontend/src/stores/authStore.js

import { defineStore } from 'pinia'

const STORAGE_KEY = 'authState'

function loadState() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY)
    return raw ? JSON.parse(raw) : { username: '', role: '', token: '' }
  } catch {
    return { username: '', role: '', token: '' }
  }
}

export const useAuthStore = defineStore('auth', {
  state: () => ({ ...loadState() }),
  getters: {
    isAuthenticated: (s) => !!s.token,
    isAdmin: (s) => s.role === 'admin',
  },
  actions: {
    setAuth(payload) {
      this.username = payload.username
      this.role = payload.role
      this.token = payload.token
      localStorage.setItem(STORAGE_KEY, JSON.stringify({ username: this.username, role: this.role, token: this.token }))
    },
    clear() {
      this.username = ''
      this.role = ''
      this.token = ''
      try {
        localStorage.clear()
      } catch {}
    }
  }
})

day16/frontend/src/pages/LoginPage.vue

<script setup>
import { ref } from 'vue'
import { http } from '../services/http'
import { useAuthStore } from '../stores/authStore'
import { useRouter } from 'vue-router'

const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const auth = useAuthStore()
const router = useRouter()

async function login() {
  error.value = ''
  loading.value = true
  try {
    const { data } = await http.post('/api/login', { username: username.value, password: password.value || '123456' })
    auth.setAuth(data)
    router.push('/order')
  } catch (e) {
    error.value = e.response?.data?.error || '登入失敗'
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <main class="page">
    <h2>登入</h2>
    <div v-if="error" class="error-message">{{ error }}</div>
    <div class="block" style="max-width:360px">
      <label>使用者名稱:<input v-model.trim="username" placeholder="例如 corgi / roni" /></label>
      <label style="display:block; margin-top:8px">密碼:<input v-model.trim="password" type="password" placeholder="預設 123456" /></label>
      <div class="actions" style="margin-top:8px">
        <button class="btn primary" :disabled="loading || !username" @click="login">登入</button>
      </div>
      <p style="font-size:12px;color:#666;margin-top:8px">說明:若此使用者不存在將自動建立(密碼固定 123456)。使用者 roni 具備 admin 權限。</p>
    </div>
  </main>
</template>

day16/frontend/src/router/index.js

import LoginPage from '../pages/LoginPage.vue'
import { useAuthStore } from '../stores/authStore'

router.beforeEach((to) => {
  const auth = useAuthStore()
  if (to.path !== '/login' && !auth.isAuthenticated) return { path: '/login', query: { redirect: to.fullPath } }
  if (to.meta?.requiresAdmin && !auth.isAdmin) return { path: '/order' }
})

App.vue 的登出按鈕與流程

day16/frontend/src/App.vue

<script setup>
import { useAuthStore } from './stores/authStore'
import { useRouter } from 'vue-router'

const auth = useAuthStore()
const router = useRouter()

function logout() {
  auth.clear()
  router.push('/login')
}
</script>

<template>
  <main class="page">
    <h1>飲料點單系統 (Router 版)</h1>
    <nav style="display:flex; gap:8px; margin:12px 0;">
      <router-link to="/order" class="btn">點餐之塔</router-link>
      <router-link to="/summary" class="btn">結算之室</router-link>
      <span style="flex:1"></span>
      <button v-if="auth.isAuthenticated" class="btn" @click="logout">登出</button>
    </nav>
    <router-view />
  </main>
  
</template>

day16/frontend/src/services/orderService.js

async getById(id, token) {
  return (await http.get(`/api/orders/${id}`, { headers: { Authorization: `Bearer ${token}` } })).data
}
const auth = useAuthStore()
order.value = await OrderService.getById(route.params.id, auth.token)

總結

為什麼這樣設計?

  • 雙層防禦:前端守衛提升體驗;後端授權擋住繞路攻擊。
  • 簡化示範:以 base64(username) 當 token 容易理解;日後可替換成 JWT + 期限
  • 持久化登入:Pinia + localStorage,重整不掉魔法。
  • 一致的出入口App.vue 置頂導覽 + 登出;任一頁都能快速回城。

本日改動檔案清單與目的

  • 後端

    • backend/server.js:新增 /api/login、加上單筆詳情授權
    • backend/user.json:使用者資料存放
  • 前端

    • src/pages/LoginPage.vue:登入流程
    • src/stores/authStore.js:登入狀態管理、持久化、清除
    • src/router/index.js:全域守衛、/summary 僅 admin
    • src/services/orderService.js:帶 Token 取單筆詳情
    • src/pages/OrderDetailPage.vue:從 store 取 token 呼叫 API
    • src/App.vue:登出按鈕與導回 /login

九、驗收清單

  • 未登入訪問 /order/summary被導向 /login
  • 使用 roni 登入可進入 /summary;其他帳號不可
  • 任一帳號登入後,僅能看自己/order/:idadmin 可看全部

https://ithelp.ithome.com.tw/upload/images/20251006/20121052iOzrmsEJCj.png

https://ithelp.ithome.com.tw/upload/images/20251006/20121052rLeDbrdDEL.png

day16 github

十、總結

今天,我們把系統外圍加上了「結界」:通行證(token)+身份(role)+守衛(guard)。
前端負責「看門」,後端負責「最後一道城門」。
從現在開始,你的飲料王國不再是一座無門的花園,而是一座秩序井然、禮制分明的魔法城市。接下來,我們將把這些結界與資料快取、錯誤顯示與重試結合,讓整體體驗更絲滑。


上一篇
Day 15.5 : 傳送門進階術 - 用 Vue Router 讓頁面與連結說話
下一篇
Chapter 3:Vue 魔法生命師法順序 — 觀察、召喚與解除的時機
系列文
需求至上的 Vue 魔法之旅24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言